Skip to content

Add layout="x" and layout="y" axis constraints#3627

Merged
mattgperry merged 2 commits intomainfrom
worktree-fix-issue-3103
Mar 12, 2026
Merged

Add layout="x" and layout="y" axis constraints#3627
mattgperry merged 2 commits intomainfrom
worktree-fix-issue-3103

Conversation

@mattgperry
Copy link
Collaborator

@mattgperry mattgperry commented Mar 10, 2026

Summary

  • Adds layout="x" and layout="y" as new values for the layout prop
  • When layout="x", only the x-axis layout change is animated — y-axis changes apply instantly
  • When layout="y", only the y-axis layout change is animated — x-axis changes apply instantly
  • Works with both layout and layoutId

Fixes #3103
Fixes #1972

How it works

In the projection system's notifyLayoutUpdate, when animationType is "x" or "y", the snapshot for the non-animated axis is set to match the current layout. This eliminates the delta on that axis so no animation occurs — the element snaps instantly to its final position on that axis while smoothly animating on the other.

Test plan

  • Added Cypress E2E test for layout="x" — verifies x-axis animates while y-axis snaps
  • Added Cypress E2E test for layout="y" — verifies y-axis animates while x-axis snaps
  • All existing layout tests still pass
  • Tests pass on both React 18 and React 19
  • Full build passes
  • All unit tests pass (92 suites, 766 tests)

🤖 Generated with Claude Code

Allows limiting layout animations to a single axis. When layout="x", only
the x-axis animates and y changes are applied instantly. When layout="y",
only the y-axis animates and x changes are applied instantly. Works with
both layout and layoutId.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR introduces layout="x" and layout="y" as new values for the layout prop, allowing developers to constrain layout animations to a single axis — the other axis snaps immediately to its final position. The implementation hooks into the existing notifyLayoutUpdate function in the projection node by zeroing out the delta on the snap axis before the animation delta is calculated.

Key changes:

  • "x" and "y" are added to the layout / animationType union types across node/types.ts, projection/node/types.ts, and LayoutAnimationBuilder.ts
  • notifyLayoutUpdate in create-projection-node.ts gains a new branch that sets snapshot[snapAxis].min/max to match the current layout, eliminating the delta on that axis
  • Two Cypress E2E tests validate the feature using a clever ease: () => 0.5 trick to assert mid-animation values on the animated axis against already-snapped values on the constrained axis

Issues found:

  • Logic bug in shared layout path: In the new "x" / "y" branch, when isShared is true, axisSnapshot already points to snapshot.measuredBox[snapAxis]. The subsequent if (isShared) block then assigns the same values to snapshot.measuredBox[snapAxis] a second time — this is a no-op. The intent was almost certainly to also update snapshot.layoutBox[snapAxis], which feeds layoutDelta. Because layoutBox[snapAxis] is never zeroed, layoutDelta on the snap axis stays non-zero for shared (layoutId) transitions, which may affect downstream consumers of didUpdate and could produce incorrect animation behaviour when axis constraints are combined with layoutId.
  • Missing test coverage: The PR description says the feature works with layoutId, but there are no Cypress tests exercising layout="x" / "y" together with layoutId. Given the identified code-path bug, a shared-layout test is needed to confirm correct behaviour.

Confidence Score: 3/5

  • Safe for non-shared layout animations, but contains a likely bug in the shared layout (layoutId) code path that needs verification before merging.
  • The non-shared path works correctly and is well-tested with cleverly designed E2E tests. However, the if (isShared) block is a clear no-op (it writes the same values to measuredBox a second time instead of writing to layoutBox), leaving layoutDelta stale on the snap axis for shared layout transitions. The PR claims layoutId compatibility but provides no tests for it, and the code analysis suggests it would behave incorrectly in that path.
  • Pay close attention to packages/motion-dom/src/projection/node/create-projection-node.ts lines 2122–2133, specifically the if (isShared) block in the new "x" / "y" branch.

Important Files Changed

Filename Overview
packages/motion-dom/src/projection/node/create-projection-node.ts Core implementation of axis-constrained layout animation; contains a redundant/incorrect if (isShared) block that updates measuredBox again instead of layoutBox, leaving layoutDelta non-zero on the snap axis for shared layout transitions.
packages/framer-motion/cypress/integration/layout.ts Adds two well-designed Cypress E2E tests using a frozen ease function (() => 0.5) to verify mid-animation state; correctly validates non-shared elements, but no coverage for the layoutId (shared) path that contains the identified bug.
packages/motion-dom/src/layout/LayoutAnimationBuilder.ts Adds `"x"
packages/motion-dom/src/node/types.ts Public API type updated to expose `"x"
packages/motion-dom/src/projection/node/types.ts Internal animationType union extended with `"x"

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[notifyLayoutUpdate called] --> B{animationType?}

    B -->|size| C[Snap position, preserve size on both axes]
    B -->|x or y| D[Determine snapAxis\nx animationType snaps y axis\ny animationType snaps x axis]
    B -->|position| E[Preserve size, animate position]
    B -->|both or default| F[Full layout animation]

    D --> G{isShared?}
    G -->|false| H[Update layoutBox snapAxis\nto match current layout]
    G -->|true| I[Update measuredBox snapAxis\nto match current layout]
    I --> J[if-isShared block re-updates\nmeasuredBox REDUNDANT\nshould update layoutBox]

    H --> K[layoutDelta snapAxis = 0]
    J --> L[layoutDelta snapAxis != 0 BUG]

    I --> M[visualDelta snapAxis = 0]
    H --> N[visualDelta snapAxis = 0]

    K --> O[Correct: only non-snap axis animates]
    N --> O
    L --> P[layoutDelta non-zero on snap axis\nfor shared layout transitions]
    M --> O
Loading

Last reviewed commit: 362e784

Comment on lines +2130 to +2133
if (isShared) {
snapshot.measuredBox[snapAxis].min = layout[snapAxis].min
snapshot.measuredBox[snapAxis].max = layout[snapAxis].max
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant assignment in the shared-layout branch

When isShared is true, axisSnapshot is already a reference to snapshot.measuredBox[snapAxis] (line 2124–2126). The subsequent if (isShared) block therefore writes the exact same values back to the exact same object — it is a no-op.

The intention was likely to also zero out the snap axis on snapshot.layoutBox, which drives the layoutDelta calculation at line 2157:

calcBoxDelta(layoutDelta, layout, snapshot.layoutBox)

Because snapshot.layoutBox[snapAxis] is never updated for shared elements, layoutDelta on the snap axis will remain non-zero for layoutId-based (shared) transitions. The visual animation still appears correct because visualDelta is computed from measuredBox, but the stale layoutDelta could affect downstream didUpdate consumers and the hasLayoutChanged gate in unexpected ways.

The fix should update layoutBox in the isShared branch:

if (isShared) {
    snapshot.layoutBox[snapAxis].min = layout[snapAxis].min
    snapshot.layoutBox[snapAxis].max = layout[snapAxis].max
}

Comment on lines +392 to +438
it(`It correctly fires layout="x" animations, only animating the x axis`, () => {
cy.visit("?test=layout&type=x")
.wait(50)
.get("#box")
.should(([$box]: any) => {
expectBbox($box, {
top: 0,
left: 0,
width: 100,
height: 200,
})
})
.trigger("click")
.wait(50)
.should(([$box]: any) => {
expectBbox($box, {
top: 100,
left: 100,
width: 200,
height: 300,
})
})
})

it(`It correctly fires layout="y" animations, only animating the y axis`, () => {
cy.visit("?test=layout&type=y")
.wait(50)
.get("#box")
.should(([$box]: any) => {
expectBbox($box, {
top: 0,
left: 0,
width: 100,
height: 200,
})
})
.trigger("click")
.wait(50)
.should(([$box]: any) => {
expectBbox($box, {
top: 50,
left: 200,
width: 300,
height: 250,
})
})
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test coverage for axis constraints with layoutId (shared layout)

The PR description states the feature "Works with both layout and layoutId", but both new Cypress tests use a plain layout="x" / layout="y" element without layoutId. The code path for shared layout transitions (isShared = true) takes a different branch inside notifyLayoutUpdate, and — as noted in the logic comment above — the if (isShared) block appears to contain a bug specific to that path.

Consider adding a test case that uses layout="x" (or "y") together with layoutId to verify that the snap axis truly snaps while the animated axis transitions correctly, and to prevent regressions in the shared-layout flow.

… block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mattgperry mattgperry merged commit 5fad98c into main Mar 12, 2026
2 of 5 checks passed
@mattgperry mattgperry deleted the worktree-fix-issue-3103 branch March 12, 2026 05:41
@looskie
Copy link

looskie commented Mar 12, 2026

amazing ship, thank you ❤️❤️🫡

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] A way to control transition when using layoutId [BUG] layoutId has incorrect positioning when height adjusts.

2 participants